Rails におけるレースコンディション
あなたがECシステムの支払い処理システムを実装していて、複数の顧客が全く同じ注文に対して二重に請求されていることを発見したと想像してみてください。...それは悪夢のようです。そして次の日には、ユーザーがクレジットカードを使わずに特別なクレジットを使って支払うことができたクレジットシステムに何かが正しくないことに気がつきました。そして、さらに最悪なことに、あなたが追加した一意性のバリデーションが全く機能していないことが判明し、今では同じメールアドレスを持つ3人のユーザーがいることが判明しました!
うまくいけば、このような悲惨な現実が避けられないわけではありません。このような厄介な問題を回避するためには、潜在的なレースコンディションに注意を払い、それが起こりうる状況を理解し、どのような防御策を講じることができるのかを理解する必要があります。
レースコンディションってなんですか
レースコンディションとは、コードの並列実行が、それらを直列実行する場合と比べて負の結果が引き起こされる望ましくない状況のことです。スレッドの扱いに慣れていると、レースコンディションは日常茶飯事のように聞こえるかもしれません。しかし、同じアプリケーションの複数のプロセスを実行しているときにも、レースコンディションは起こりやすいのです。一見すると直感的ではないように思えるかもしれませんが、ほとんどの場合、プロセス間で共有されているコンポーネントがあることを覚えておいてください。Ruby on Railsアプリケーションでは、ほとんどの場合、それはデータベースになるでしょう。
ここでは、いくつかの一般的なレースコンディションのシナリオとそれに対する防御策を見てみましょう。
マルチスレッドサーバーとグローバル変数
グローバル変数は良いアイデアではないという言説は、いたってありふれてるものです。しかし、マルチスレッド環境で運用する場合、グローバル変数の問題はかなり深刻になります。Railsアプリの注目すべき例としては、Puma Webサーバが挙げられます。デフォルトでは、すべてのPumaインスタンスは最大5スレッドを使用します。このアプローチには確かに大きなメリットがあります。一方で、注意を怠るとグローバル変数を使ったレースコンディションに簡単にさらされる可能性があります。
カレントユーザのIPを入れるグローバル変数というアンチパターンを見てみましょう。このパターンを実装する「創造的な」方法の一つは、クラスレベルの属性リーダとライタに User.current_ip と User.current_ip= メソッドを持たせることです。
code:rb
class User < ApplicationRecord
@@current_ip = nil
def self.current_ip
@@current_ip
end
def self.current_ip=(ip)
@@current_ip ||= ip
end
end
User.current_ip がコントローラのアクションごとに ApplicationController に設定されていて、後から書き込みアクションを行う際に保持されるとしましょう。
監査ログを作成するという当初の考えは良いものでしたが、もしこのような実装を採用することになった場合、 間違った IP アドレスが保持されてしまっても驚かないようにしましょう。しかし、実際の問題は何でしょうか?
クラス変数としてのグローバル変数は、同じプロセス内のすべてのスレッドで共有されます。5 つのスレッドを持つ単一の Puma ワーカーがあり、ほぼ同時に 2 つのリクエストがあったと想像してみてください。ユーザーAがパスワードを変更することにしました。新しいパスワードを入力してsaveボタンをクリックした。仮説的な UsersController#update アクションが実行され、適切な IP が設定されました。ほぼ同時期に、ユーザーBがあるページにアクセスし、再びIPアドレスが設定されます。パスワードの変更は、`User.current_ip= とともに保持されます。
このような場合、ユーザBがユーザAのIPアドレスを変更したように見えますが、その変更のために保存されるのは彼ら2人のIPアドレスかもしれません。これは、グローバル変数を使用する場合に起こりやすい典型的なレースコンディションです。
レースコンディションのリスクなしにグローバル変数を使用することはできないということでしょうか?
もし本当にグローバル変数が唯一の方法であるならば(ヒント: 普通はそんなことありません)、スレッドローカル変数を使用することができます。これは、値を読み込んだり設定したりするためのハッシュのようなメソッドを公開しているので、とても便利に使えます。
code:rb
class User < ApplicationRecord
def self.current_ip
end
def self.current_ip=(ip)
end
end
こうすれば、グローバル変数を使ってもレースコンディションを防ぐことができます(醜いコード防ぐことはできませんが)。
データベースの制約が足りない
典型的な例として、サインアッププロセスを見てみましょう。ユーザーにメール、パスワード、パスワードの確認を求め、sign up ボタンをクリックしてサインアップさせます。同じメールアドレスで複数のユーザーレコードを持つことは避けたいので、一意性のバリデーションを追加します。
code:rb
class User < ApplicationRecord
validates :email, uniqueness: true
end
あるユーザーが連続して3回ボタンを素早くクリックした場合に何が起こるか心当たりはありませんか?3つのレコードが作成される可能性が高く、一意性のバリデーションはなんの仕事もしません。
ここで理解しておくべき重要なことは、一意性のバリデーションは一意性を保証するものではなく、どちらかというとレースコンディションがない場合に有用なバリデーションエラーを表示するためのものであるということです。ボタンを素早く3回連続でクリックすると、次のようなシナリオが発生する可能性が高いです。
リクエスト1 - ユーザーが存在するかどうかをチェックし、そのメールアドレスを持つユーザーが見つからないので、次に進みます。
リクエスト2 - ユーザーが存在するかどうかをチェックし、そのメールアドレスを持つユーザーが見つからないので、次に進みます。
リクエスト3 - ユーザーが存在するかどうかをチェックし、そのメールアドレスを持つユーザーが見つからないので、次に進みます。
リクエスト1 - データベースにデータを挿入するコードに到達します。新しいユーザーレコードが作成されます。
リクエスト 2 - データベースにデータを挿入するコードを確認します。新しいユーザーレコードが作成されます。
リクエスト 3 - データベースにデータを挿入するコードに到達します。新しいユーザーレコードが作成されます。
すべてのシナリオでこの種のレースコンディションから身を守る唯一の方法は、データベースレベルで一意のインデックスを追加することです。あるいは、勧告的ロックを使用してコードの同時実行を防ぐこともできます (これについては記事の後半で説明します)。
この問題のもう一つのバリエーションは、レースコンディションによって引き起こされる可能性があると同時に、適切なデータベース制約の欠如が問題となっています。旅行者Aが2021年6月3日から2021年6月10日までの間の滞在を予約したいとし、旅行者Bが2021年6月2日から2021年6月7日までの間の滞在を予約したいとし、ほぼ同時に予約を作成した場合を想像してみてください - 日付が利用可能かどうかをチェックするバリデーションは、データの整合性の限りでは無意味です。
この問題を解決するには、データベースレベルで処理する必要があります。この特定の例では、PostgreSQLの tsrange を利用することができます。日付範囲の一意性を保証する典型的な例は以下のようになります。
code:sql
ALTER TABLE reservations ADD CONSTRAINT no_overlapping_reservations EXCLUDE USING gist(property_id WITH =, tsrange(start_at, end_at, '[)') WITH &&);
悲観的ロック
記事の冒頭の例に戻りましょう。あなたのECシステムでは、クレジットカードで支払うか、ユーザーが追加できるプリペイドクレジットで支払うことができると想像してみてください。ある仮想的なユーザーが100クレジットを持っていて、合計価格が25ドルと75ドルの2つの注文の支払いにちょうど十分な金額です。興味深いことに、このユーザーはソフトウェア開発について何かを知っており、システムがいくつかの厄介なエッジケースを処理できるかどうかを確認したいと考え、ちょっとした実験をすることにしました:これらの2つの注文の支払いをほぼ同時に行い、何が起こるかを確認します。2つの注文の支払いをほぼ同時に行い、どうなるかを確認してみました。
うわ、かなりやばそうですね?コードを見てみましょう。
code:rb
def charge_user_for_order(user_id, order_id)
user = User.find(user_id)
order = Order.find(order_id)
ActiveRecord::Base.transaction do
user.credits -= order.price
user.save!
order.paid!
end
end
ユーザーと注文を見つけて、ユーザーからクレジットを差し引き、変更を持続させ、注文を支払い済みにする方法があります。これは私たちが期待していたものですが、何か問題があるのでしょうか?
ここでの問題は、-= 操作の結果です。両方の注文の支払い後に0になると予想していたかもしれませんが、コードを同時に実行すると、次のようなシナリオが起こる可能性が高いです。
リクエストA: ユーザーと注文をロード
リクエストB: ユーザと注文をロード
リクエストA: ユーザーのクレジットを100 - 75に設定し、変更を継続します。
リクエストB: ユーザーのクレジットを100 - 25に設定し、変更を継続します。
そして、それはまさに注文が支払われ、75クレジットが利用可能な状態で終わることになります。
幸いなことに、これを回避するのは簡単です: ActiveRecord の悲観的ロック機能とそのlock!メソッドを使用してcharge_user_for_order メソッド内で行レベルのロックを取得する必要があります。
code:rb
def charge_user_for_order(user_id, order_id)
user = User.find(user_id)
order = Order.find(order_id)
ActiveRecord::Base.transaction do
user.lock! # do not forget about this!
user.credits -= order.price
user.save!
order.paid!
end
end
この場合、ActiveRecord は SELECT FOR UPDATE クエリを実行し、他のトランザクション内でのそのレコードの変更を防止します (または、そのトランザクション内でロックされている限り、並行トランザクションが終了するまで待ちます)。また、それは内部的に(reloadメソッドで)レコードをリロードします。その方法によって、我々は確実に最新のデータを操作することができます。
勧告的ロック
コードの同時実行を防ぐ必要があることはよくありますが、ロックする必要があるのはデータベーステーブルやデータベース行ではありません。その代わりに、コードが順次実行されるように、同じプロセス内のスレッドだけでなく、複数のプロセスに適用できるRuby Mutexのようなものが必要です。
では、勧告的ロックが役に立つのはどのような場合でしょうか?Amazon S3からファイルを取得し、その内容を変更して、変更後にアップロードしなおす必要がある場合を想像してみてください。より正確には、バックグラウンドでこのロジックを処理するSidekiqワーカーがいて、コードは以下のようになっています。
code:rb
class S3Worker
include Sidekiq::Worker
def perform(*args)
fetch_file
modify_file
upload_file
end
end
このコードを5人のワーカーが同時に実行するとどうなるのでしょうか?ファイルのフェッチとアップロードがいつ行われるかによって、非常に異なる結果を得ることができますが、最終的には大きな矛盾が生じることは間違いありません。また、ジョブを1つずつ実行するのと比較して、結果は非常に異なるものになるでしょう。
このコードを5人のワーカーが同時に実行するとどうなるのでしょうか?ファイルのフェッチとアップロードがいつ行われるかによって、非常に異なる結果を得ることができますが、最終的には大きな矛盾が生じることは間違いありません。また、ジョブを1つずつ実行するのと比較して、結果は大きく異なるものになるでしょう。
これは重大な潜在的な問題のように聞こえますが、幸いなことに、勧告的ロックはコードの同時実行を簡単に防ぐことができます。with_advisory_lock gemを使うと、このようなコードになります。 code:rb
class S3Worker
include Sidekiq::Worker
def perform(*args)
ActiveRecord::Base.with_advisory_lock("S3Worker-Lock") do
fetch_file
modify_file
upload_file
end
end
end
S3Worker-Lockというロックが取得されると、同じロックを取得しようとするすべてのプロセスは、既存のロックが解放されるまで待つ必要があります。このようにして、複数のプロセスを持つことによる並列性と同時に、ジョブの直列実行を保証することができます。
さらに興味深いのは、悲観的ロックを勧告的なロックに置き換えることができるということです。例えば、異なるトランザクション内で同じ行の更新が頻繁に発生し、かなりの時間がかかり、それぞれが終了を待つことが必須で、それぞれの更新は共通の更新をなにも持っていない場合があります。
これは、先ほどのクレジットの例でも当てはまるかもしれません。このようにして、行レベルのロックの代わりに勧告的ロックを使用するようにコードを書き換えることができます。
code:rb
def charge_user_for_order(user_id, order_id)
ActiveRecord::Base.transaction do
ActiveRecord::Base.with_advisory_lock("charge_user_for_order_#{user_id}_#{order_id}") do
user = User.find(user_id)
order = Order.find(order_id)
user.credits -= order.price
user.save!
order.paid!
end
end
end
他のトランザクションで同じ属性を変更しない限り、悲観的ロックを使用しなくても問題ありません。しかし、userやorderの同じ属性が他の場所でも変更されていた場合、デッドロックが発生する可能性があります。
悲観的ロックの代わりに勧告的なロックを使用する際に注意すべき重要なことが一つあります - レコードを読み込む際には、コード内でレコードのreloadがないので注意してください! これは、当初悲観的ロックで修正しようとしていたのと同じ問題を引き起こす可能性があります。例えば、勧告的なロックはここではほとんど意味がありません。
code:rb
def charge_user_for_order(user_id, order_id)
user = User.find(user_id)
order = Order.find(order_id)
ActiveRecord::Base.transaction do
ActiveRecord::Base.with_advisory_lock("charge_user_for_order_#{user_id}_#{order_id}") do
user.credits -= order.price
user.save!
order.paid!
end
end
end
orderもuserも勧告的ロックを取得する前に読み込まれているので、コードの同時実行をブロックしても、古いレコードデータに対して操作を実行してしまう可能性があります。そのため、一部のロジックだけではなく、全体のロジックの実行を勧告的ロック内でラップする必要があります。
Redlock
PostgreSQLを使わなくても、S3の操作のようなユースケースで分散ロック/ミューテックスが必要な場合はどうでしょうか?Redisを使用しているのであれば、それは問題ではありません。Redlock - 分散ロックを実装したアルゴリズム - によるソリューションを使うことができます。 redlock-rbは、Railsアプリで簡単に使えるようにしてくれるので、自分で考える必要がありません。ただし、これは少し使い方を変える必要があるので、勧告的ロックの代わりにはなりません。以下の例をチェックしてみてください。 code:rb
expiration_time_in_milliseconds = 60_000
first_lock = lock_manager.lock("S3Worker-Lock", expiration_time_in_milliseconds)
# => almost immediately returns a hash containing data about the lock
second_lock = lock_manager.lock("S3Worker-Lock", expiration_time_in_milliseconds)
# => almost immediately returns false
勧告的ロックを使用した場合(少なくとも with_advisory_lock メソッドを経由した場合、少し動作が異なる with_advisory_lock_result メソッドもあります)、2 番目のジョブは 1 番目のロックが解放されるまで待ってからロジックを実行することになります。Redlock の場合、2 番目のジョブはロジックを実行せずにすぐに戻ってきます。同じことを 2 回実行する必要がないので、実際にはこれが望ましい動作かもしれません。それでも、元の例と同じ動作をしたいのであれば、後でジョブを再試行するなどして、自分たちで対処する必要があります。
楽観的ロック
システムで複数のユーザーが同じレコードを操作できるようにすると、重大な問題が発生する可能性があります。例えば、誰かが決定行動をするたびに、最新のデータに基づいて行われることを保証したい場合があります。例えば、顧客への返金を行うことで、その顧客に後から購入に使用できる特別なクレジットを付与することができます。複数の人がそれを行い、ほぼ同時に返金があることを知った場合、クレジットが二重に付与されるという不幸なシナリオが発生する可能性があります。このようなケースでは、2回目の操作を許可しないことが望まれますが、これは楽観的ロックに帰着します。
これはRailsで実現するには非常に簡単なことで、モデルのテーブルにlock_versionカラムを追加するだけで済み、余分な設定を追加する必要はありません。ActiveRecordは更新のたびにそれをインクリメントしてくれます。このようにして、ActiveRecord は、特定のレコードの lock_version が変更された場合、ActiveRecord::StaleObjectError を発生させてレコードの更新を阻止します。
code:rb
user_1_a = User.find(1)
user_1_b = User.find(1)
user_1_a.credits += 100
user_1_a.save!
# => works just fine
user_1_b.credits += 100
user_1_b.save!
# => raises ActiveRecord::StaleObjectError
まとめ
レースコンディションは、マルチスレッドコードの外であっても、それが起こる前に防がなければ、非常に恐ろしい結果を招く可能性があります。幸いなことに、いくつかの可能性のあるシナリオとそれを緩和する方法を理解することで、この種の問題を簡単に回避することができます。